/*
 * Copyright 2009 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.google.beepmystuff;

import com.google.beepmystuff.BmsClient.ShareWith;

import java.util.ArrayList;
import java.util.List;

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.SQLException;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.provider.BaseColumns;
import android.text.TextUtils;
import android.util.Log;

/**
 * Holds the details of pending and recent scans.
 * Data is backed in a sqlite3 database, holding the EAN, the current state of
 * the item (pending add, waiting for graphics etc), and the item's details for
 * a successful scan. The item's image is cached here too.
 * 
 * Instances of this class are thread-safe.
 * TODO: Or at least, should be.  Determine if that's true.
 */
public class ScanRegistry {
  private SQLiteDatabase db;
  private static final String TAG = "ScanRegistry";
  private static final String DEFAULT_DB_NAME = "scan.db";
  private static final int DB_VERSION = 6;

  private static final String SCAN_TABLE_NAME = "scans";
  public static final String FIELD_EAN = "EAN";
  public static final String FIELD_STATE = "STATE";
  public static final String FIELD_STATUS = "STATUS";
  public static final String FIELD_TITLE = "TITLE";
  public static final String FIELD_IMAGE_URL = "IMGURL";
  public static final String FIELD_IMAGE_DATA = "IMGDATA";
  private static final int MAX_IMAGE_DATA_SIZE = 8192;
  public static final String FIELD_SCANNED_DATE = "SCANDATE";
  public static final String FIELD_UPDATED_DATE = "LASTUPDATE";
  public static final String FIELD_LAST_NETWORK_ATTEMPT = "LASTNETWORK";
  public static final String FIELD_SHARE_WITH = "SHAREWITH";

  private static final int STATE_PENDING_ADD = 0;
  private static final int STATE_ADD_FAILED = 1;
  private static final int STATE_PENDING_IMAGE = 2;
  private static final int STATE_COMPLETE = 3;

  private static final long MIN_RETRY_TIME_MS = 5 * 60 * 1000;

  /**
   * An interface used to supply clock information to the database. Can be used
   * to mock out the time source during testing.
   */
  public interface Clock {
    /**
     * Gets the time "now", measured in milliseconds since some epoch.
     * @return milliseconds since the epoch
     */
    long now();
  }

  /**
   * An interface which receives notifications when the underlying data
   * changes.
   */
  public interface Listener {
    /**
     * Notifies the listener that the scan registry has changed.
     */
    void notifyChanged();
  }

  /**
   * Internal private class used to create or upgrade the sqlite3 database
   * as necessary.
   */
  private static class DatabaseHelper extends SQLiteOpenHelper {
    DatabaseHelper(Context context, String dbName) {
      super(context, dbName, null, DB_VERSION);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
      db.execSQL("CREATE TABLE " + SCAN_TABLE_NAME + " (" + BaseColumns._ID
          + " INTEGER PRIMARY KEY AUTOINCREMENT," + FIELD_EAN + " TEXT NOT NULL," + FIELD_STATE
          + " INTEGER NOT NULL," + FIELD_STATUS + " TEXT," + FIELD_TITLE + " TEXT,"
          + FIELD_IMAGE_URL + " TEXT," + FIELD_IMAGE_DATA + " BLOB(" + MAX_IMAGE_DATA_SIZE + "),"
          + FIELD_SCANNED_DATE + " INTEGER," + FIELD_UPDATED_DATE + " INTEGER,"
          + FIELD_SHARE_WITH + " INTEGER, "
          + FIELD_LAST_NETWORK_ATTEMPT + " INTEGER" + ");");
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
      Log.w(TAG, "Upgrading database from version " + oldVersion + " to " + newVersion
          + ", which will destroy all old data");
      db.execSQL("DROP TABLE IF EXISTS " + SCAN_TABLE_NAME);
      onCreate(db);
    }
  }

  private Clock clock;
  private List<Listener> listeners = new ArrayList<Listener>();

  /**
   * Constructs a ScanRegistry with the given global context.
   * The clock is taken to be one which uses the System's currentTimeMillis().
   * @param context global context of the application
   */
  public ScanRegistry(Context context) {
    this(context, new Clock() {
      public long now() {
        return System.currentTimeMillis();
      }
    }, DEFAULT_DB_NAME);
  }

  /**
   * Constructs a ScanRegistry from a global context, using the given Clock,
   * and database filename.
   * @param context global application context
   * @param clock clock to use for times
   * @param databaseFilename name of the database file to use, or null for a
   * temporary in-memory database.
   */
  public ScanRegistry(Context context, Clock clock, String databaseFilename) {
    DatabaseHelper helper = new DatabaseHelper(context, databaseFilename);
    db = helper.getWritableDatabase();
    this.clock = clock;
  }

  /**
   * Adds a listener to the list of objects to notify when the data in the
   * ScanRegistry changed.
   * @param listener listener to add
   */
  public void addListener(Listener listener) {
    listeners.add(listener);
  }

  /**
   * Removes a previous added listener.
   * If the listener wasn't previously added, this function does nothing.
   * @param listener to remove
   */
  public void removeListener(Listener listener) {
    listeners.remove(listener);
  }

  /**
   * Closes the ScanRegistry.
   * Calling any methods after this has undefined behaviour.
   */
  public void close() {
    db.close();
    db = null;
  }
  
  /**
   * Walks the list of listeners, and notifies them that a change has occurred.
   */
  private void notifyChanged() {
    for (Listener listener : listeners) {
      listener.notifyChanged();
    }
  }

  /**
   * Adds an EAN to the scan registry.
   * The EAN is not a unique key, ie the same item can be scanned multiple times.
   * The EAN's state is marked as "pending add"
   * @param ean EAN to add, as a string.
   * @param shareWith who to share this item with
   * @return unique id of this EAN's instance
   */
  public long addEan(String ean, ShareWith shareWith) {
    ContentValues values = new ContentValues();
    values.put(FIELD_EAN, ean);
    values.put(FIELD_STATE, STATE_PENDING_ADD);
    values.put(FIELD_STATUS, "Queued");
    Long now = clock.now();
    values.put(FIELD_SCANNED_DATE, now);
    values.put(FIELD_UPDATED_DATE, now);
    values.put(FIELD_SHARE_WITH, shareWith.ordinal());
    long id = db.insert(SCAN_TABLE_NAME, FIELD_EAN, values);
    if (id < 0) {
      throw new SQLException("Unable to add ean");
    }
    notifyChanged();
    return id;
  }

  /**
   * An Item holds information about an existing entry in the ScanRegistry.
   * It is mutable, and operations performed on it are reflected in the
   * registry.
   */
  public class Item {
    private final long id;
    private final String ean;
    private final String imageUrl;
    private final ShareWith shareWith;

    /**
     * Create an Item from the given unique id, EAN, image URL (which may be
     * null or empty), and sharing options.
     */
    private Item(long id, String ean, String imageUrl, ShareWith shareWith) {
      this.id = id;
      this.ean = ean;
      this.imageUrl = imageUrl;
      this.shareWith = shareWith;
    }

    /**
     * Returns the EAN associated with this Item.
     */
    public String getEan() {
      return ean;
    }

    /**
     * Returns the image URL associated with this Item. This may be empty or
     * null if the image information has not yet been fetched, or if there is
     * no image for this Item.
     */
    public String getImageUrl() {
      return imageUrl;
    }

    /** Returns who this item is to be shared with. */
    public ShareWith shareWith() {
      return shareWith;
    }

    /**
     * Helper function used to update the database, filling in common fields
     * like updated time and last failed network attempt.
     * @param values a set of values to update
     * @param failedAttempt true if this update was a failed network attempt
     */
    private void updateDatabase(ContentValues values, boolean failedAttempt) {
      Long now = clock.now();
      values.put(FIELD_LAST_NETWORK_ATTEMPT, failedAttempt ? now : 0);
      values.put(FIELD_UPDATED_DATE, now);
      db.update(SCAN_TABLE_NAME, values, BaseColumns._ID + " == ?", new String[] { Long
          .toString(id) });
      notifyChanged();
    }

    /**
     * Note that adding this Item to the BeepMyStuff server failed.
     * @param reason the human-readable information on why the operation
     * failed.
     * @param fatal true if this was a fatal failure and should not be retried.
     */
    public void addFailed(String reason, boolean fatal) {
      ContentValues values = new ContentValues();
      if (fatal) {
        values.put(FIELD_STATE, STATE_ADD_FAILED);
      }
      values.put(FIELD_TITLE, "Unknown");
      values.put(FIELD_STATUS, reason);
      updateDatabase(values, true);
    }

    /**
     * Note that this Item was successfully added to the BeepMyStuff server.
     * @param message message returned by the server
     * @param title title of the added item
     * @param imageUrl URL of any image data. May be empty or null if there is
     * no image associated with this Item.
     */
    public void addSucceeded(String message, String title, String imageUrl) {
      ContentValues values = new ContentValues();
      if (TextUtils.isEmpty(imageUrl)) {
        values.put(FIELD_STATE, STATE_COMPLETE);
      } else {
        values.put(FIELD_STATE, STATE_PENDING_IMAGE);
        values.put(FIELD_IMAGE_URL, imageUrl);
      }
      values.put(FIELD_STATUS, message);
      values.put(FIELD_TITLE, title);
      updateDatabase(values, false);
    }

    /**
     * Note that fetching an image for this item failed.
     * @param fatal true if the fetch failure was fatal and no further fetches
     * should be performed.
     */
    public void imageFetchFailed(boolean fatal) {
      ContentValues values = new ContentValues();
      if (fatal) {
        values.put(FIELD_STATE, STATE_COMPLETE);
      }
      updateDatabase(values, true);
    }

    /**
     * Note that an image fetch has succeeded.
     * @param imageData the raw image data
     */
    public void imageFetchSucceeded(byte[] imageData) {
      ContentValues values = new ContentValues();
      values.put(FIELD_STATE, STATE_COMPLETE);
      values.put(FIELD_IMAGE_DATA, imageData);
      updateDatabase(values, false);
    }

    /**
     * Returns the unique identifier of this Item.
     */
    public long getId() {
      return id;
    }
  }

  /**
   * Wraps an entry in the registry (determined by a Cursor) in an Item
   * instance.
   * @param cursor cursor representing the Item.
   * @return an Item wrapping the entry
   */
  public Item createItemFromCursor(Cursor cursor) {
    int idColumn = cursor.getColumnIndexOrThrow(BaseColumns._ID);
    int eanColumn = cursor.getColumnIndexOrThrow(FIELD_EAN);
    int imageColumn = cursor.getColumnIndexOrThrow(FIELD_IMAGE_URL);
    int shareWithColumn = cursor.getColumnIndexOrThrow(FIELD_SHARE_WITH);
    return new Item(
        cursor.getLong(idColumn),
        cursor.getString(eanColumn),
        cursor.getString(imageColumn),
        ShareWith.values()[cursor.getInt(shareWithColumn)]);
  }

  /**
   * Returns the next Item to add to the BeepMyStuff server.
   * The next Item is the most recently added Item which hasn't yet been
   * fetched, or the most recently non-fatally failed item that hasn't been
   * retried within the MIN_RETRY_TIME_MS period.
   * @return an Item that requires being added to BeepMyStuff, or null if none
   */
  public Item getNextItemToAdd() {
    long cutOffTime = clock.now() - MIN_RETRY_TIME_MS;
    String selection = FIELD_STATE + " == " + STATE_PENDING_ADD + " AND ("
        + FIELD_LAST_NETWORK_ATTEMPT + " IS NULL OR " + FIELD_LAST_NETWORK_ATTEMPT + " < ? )";
    Cursor cursor = db.query(SCAN_TABLE_NAME, null, selection,
        new String[] { Long.toString(cutOffTime) }, null, null, FIELD_UPDATED_DATE, "1");

    Item item = null;
    if (cursor.moveToNext()) {
      item = createItemFromCursor(cursor);
    }
    cursor.close();
    return item;
  }

  /**
   * Returns a sqlite3 Cursor object suitable for displaying the information in
   * the ScanRegistry.
   */
  public Cursor getDisplayCursor() {
    Cursor cursor = db.query(SCAN_TABLE_NAME, null, null, null, null, null, FIELD_SCANNED_DATE
        + " DESC");
    return cursor;
  }

  /**
   * Returns the next Item whose image requires fetching.
   * The next Item is determined similarly to the logic in getNextItemAdd.
   * @return the next Item to fetch the image for, or null if none
   */
  public Item getNextItemToGetImage() {
    long cutOffTime = clock.now() - MIN_RETRY_TIME_MS;
    String selection = FIELD_STATE + " == " + STATE_PENDING_IMAGE + " AND ("
        + FIELD_LAST_NETWORK_ATTEMPT + " IS NULL OR " + FIELD_LAST_NETWORK_ATTEMPT + " < ? )";
    Cursor cursor = db.query(SCAN_TABLE_NAME, null, selection,
        new String[] { Long.toString(cutOffTime) }, null, null, FIELD_UPDATED_DATE, "1");

    Item item = null;
    if (cursor.moveToNext()) {
      item = createItemFromCursor(cursor);
    }
    cursor.close();
    return item;
  }

  /**
   * Returns whether an item is awaiting an image, based on its status code and
   * its image url.
   */
  public static boolean isStatePendingImage(int state) {
    return state == STATE_PENDING_ADD || state == STATE_PENDING_IMAGE;
  }
}
